Skip to main content

03 - 路由与容错:选哪个后端,怎么算出来的

网关最核心的一次决策发生在毫秒级:同一个模型名下挂着 N 个后端,这次请求发给谁。

本篇会用到的词

意思
deployment(后端)同一个模型名底下挂的一个具体可调地址。比如「gpt-4o」这个名字下可能挂着三把不同的 key、两个不同区域的端点,每一个都是一个 deployment
TPM / RPMTokens Per Minute / Requests Per Minute,provider 给每把 key 定的两种限额。路由必须同时看这两个数,否则会把请求发给一个马上要返回 429 的后端
429HTTP 状态码 Too Many Requests,即被限流了
P99把所有请求的耗时排序后第 99 百分位的值。它比平均值更能反映「最差的那批用户体验如何」
buffer(缓冲档)最低延迟策略里的一个阈值:和最快值相差在这个范围内的后端算作同一档,档内随机选。没有它,所有请求会一起涌向当前最快的那一个
冷却(cooldown)后端连续失败后被暂时移出可用列表的那段时间,到点自动放回
CASCompare-And-Swap,比较并交换。一种无锁的并发写入手段:只有当前值和我读到的一样时才写进去,否则重试

LiteLLM 把这个决策抽象成了 router_strategy/ 目录下的一组策略类,每个策略回答同一个问题的不同版本。

litellm/router_strategy/
├── lowest_latency.py 542 行 ← 最低延迟
├── lowest_tpm_rpm_v2.py 624 行 ← 最少已用配额
├── lowest_cost.py 305 行 ← 最低单价
├── budget_limiter.py 843 行 ← 预算过滤
├── tag_based_routing.py 668 行 ← 标签匹配
└── complexity_router/ 119 KB ← 按请求复杂度选模型

前置01 - 网关是什么 里的三个词 —— deployment(一个具体可调用的模型端点)、TPM / RPM(provider 给的每分钟 token / 请求硬上限)、fallback(失败了换一个)。这三个不清楚的话先回去看一眼,本篇全程用它们。

本篇回答:同一个模型名下挂着 5 个 deployment,网关凭什么选中其中一个?这个决策的真实代码长什么样、藏着哪些坑。

读法建议:第一节(最低延迟)是全篇最重要的,四个步骤每一步都对应一个真实的线上故障。看懂它,后面几种策略都是同一套骨架换个排序依据。

一、最低延迟策略

最低延迟策略的选择过程分四步,每一步都对应一个真实的线上故障:

最低延迟策略:一次选择要走的四步① 取延迟数据Redis 里每个后端最近 N 次的延迟TTL 1 小时,上限 10 条② 选指标流式请求用 TTFT非流式用总延迟③ 硬过滤本分钟已用 TPM / RPM加上本次预估量超限的直接排除④ 分档随机排序后取最快值± buffer 内算同一档档内随机选一个少了这一步,就会复现下面这个线上故障用全时段平均值,后端刚恢复也要等很久才会被重新选中流式请求的总耗时被输出长度带偏,回答长的后端被误判成慢把请求发给一个马上就要返回 429 的后端延迟再低也是白搭所有请求涌向当前最快的那一个,把它压慢再集体切去下一个
第三步和第四步是这套实现里最容易被简化掉、也最不该简化的两步。后面几种路由策略都是这套骨架换一个排序依据。

第三步与第四步是这套实现里最容易被简化掉、也最不该简化的两步 —— 原因见 1.3 与 1.4 节。

lowest_latency.py 的核心是 _get_available_deployments()。整个流程分成四步,每一步都藏着一个工程决策。

同一次请求,五个 deployment 在四步里的去留步骤 1–2 · 取样本并选指标deployment A  最近 10 次均值 120 msdeployment B  340 msdeployment C  95 ms ← 最快deployment D  110 msdeployment E  800 ms步骤 3 · 按本分钟配额硬过滤A 通过B 通过C 出局:本分钟 TPM 已用满D 通过E 通过步骤 4 · 分档后在档内随机D 110 ms ← 最快,作为基准A 120 ms 在 ± buffer 内,同档B 340 ms 超出档外E 800 ms 超出档外结果:在 D 与 A 之间随机选一个注意 C 才是延迟最低的那个(95 ms),但它这一分钟的配额已经用完。只按延迟排序的实现会把请求送给它,然后收到 429。
把四个步骤套到一组具体数字上,第三步与第四步的必要性就直观了:硬过滤挡住了「最快但发不出去」的后端,分档随机挡住了「所有人涌向同一个最快后端」。

四步里最容易被忽略的是步骤 4 的随机——下面会看到它防的是一个真实会炸的线上问题。

1.1 延迟数据的存储位置与时效

class RoutingArgs(LiteLLMPydanticObjectBase):
ttl: float = 1 * 60 * 60 # 1 hour
lowest_latency_buffer: float = 0
max_latency_list_size: int = 10

每个后端只保留最近 10 次延迟,TTL 一小时。 不是滑动窗口平均,不是 EWMA,就是一个定长列表。写入时:

if len(request_count_dict[id].get("latency", [])) < self.routing_args.max_latency_list_size:
request_count_dict[id].setdefault("latency", []).append(final_value)
else:
request_count_dict[id]["latency"] = request_count_dict[id]["latency"][:-1] + [final_value]

这个实现有个容易忽略的性质:列表满了之后,替换的是最后一个元素,而不是最老的元素。 也就是说前 9 个样本一旦写进去就再也不会被挤出,只有第 10 个位置在滚动。延迟统计因此会带上很重的历史惯性 —— 这在后端性能长期稳定时无所谓,但在后端刚从故障中恢复时,会让它长时间"背着旧账"。

1.2 流式请求用 TTFT,非流式用总延迟

use_ttft = (
request_kwargs is not None
and request_kwargs.get("stream", None) is not None
and request_kwargs["stream"] is True
and len(item_ttft_latency) > 0
)
if use_ttft:
for _call_latency in item_ttft_latency:
if isinstance(_call_latency, float):
total += _call_latency
item_latency = total / len(item_ttft_latency)
else:
# 用总延迟

这一步是整个策略里最正确的设计。 流式场景下用户感知的是首字延迟(TTFT),非流式场景下感知的是总耗时,两者根本不是同一个指标。而且 TTFT 在记录时还做了归一化:

time_to_first_token = safe_divide_seconds(ttft_seconds, completion_tokens)

除以了输出 token 数 —— 严格说这算出来的是"每 token 的平均首字延迟",语义上有点怪,但它让长短不一的请求可以横向比较。

1.3 先按配额硬过滤,再排序

if (
item_tpm + input_tokens > _deployment_tpm or item_rpm + 1 > _deployment_rpm
):
continue
else:
potential_deployments.append((_deployment, item_latency))

配额是按分钟粒度记的,key 长这样:

current_date  = datetime.now().strftime("%Y-%m-%d")
current_hour = datetime.now().strftime("%H")
current_minute = datetime.now().strftime("%M")
precise_minute = f"{current_date}-{current_hour}-{current_minute}"

注意这是自然分钟对齐,不是滑动窗口。 意味着每分钟的第 0 秒配额会瞬间清零,突发流量可以在分钟交界处打出两倍于限额的量。这是所有用"当前分钟做 key"的限流实现的通病,第 04 篇会看到 Envoy 用完全不同的方式处理它。

1.4 在最快的一档内随机选取

sorted_deployments = sorted(potential_deployments, key=lambda x: x[1])
lowest_latency = sorted_deployments[0][1]

buffer = self.routing_args.lowest_latency_buffer * lowest_latency
valid_deployments = [x for x in sorted_deployments if x[1] <= lowest_latency + buffer]

random_valid_deployment = random.choice(valid_deployments)

这是整个策略里最关键的一行工程智慧。 如果严格选最快的那个,所有请求会瞬间涌向同一个后端,把它打慢,然后集体切到下一个 —— 典型的羊群效应。加一个 buffer 让"差不多快"的后端都进入候选,再随机选,把抖动抹平。

而且在这之前还有一次洗牌:

# randomly sample from all_deployments, incase all deployments have latency=0.0
_all_deployments = random.sample(list(_items), len(_items))

冷启动时所有后端延迟都是 0,如果不洗牌,sorted 的稳定性会让所有请求全打给列表里的第一个。这行注释写得很朴实,但它防的是一个真实会炸的线上问题。

二、最低成本策略与它的默认值风险

lowest_cost.py 的逻辑简单得多 —— 算单价,排序,取第一个:

potential_deployments = sorted(potential_deployments, key=lambda x: x[1])
selected_deployment = potential_deployments[0][0]

注意它没有 buffer + 随机那一套,因为成本是确定值不会抖动。

真正值得注意的是取不到价格时的兜底:

if item_input_cost is None:
item_input_cost = item_litellm_model_cost_map.get("input_cost_per_token", 5.0)

if item_output_cost is None:
item_output_cost = item_litellm_model_cost_map.get("output_cost_per_token", 5.0)

# if litellm["model"] is not in model_cost map -> use item_cost = $10
item_cost = item_input_cost + item_output_cost

价格表里查不到的模型,单价按 5.0 + 5.0 算。 这是一个天文数字级别的惩罚性默认值(真实模型的 input_cost_per_token 通常在 1e-6 量级),效果等于"未知模型永远不会被选中"。

这个设计是对的 —— 成本路由下选一个不知道价格的模型是不可接受的风险。但它有个副作用:自建的、私有的、刚上线还没进 model_cost 表的模型,在成本路由下会被静默地完全排除。 排查这类"我的模型怎么永远没流量"的问题时,这是第一个该看的地方。

三、标签路由

tag_based_routing.py 的文件头把语义写得很清楚:

- If tags in request is a subset of tags in deployment, return deployment
- if deployments are set with default tags, return all default deployment
- If no default_deployments are set, return all deployments

匹配函数支持两种模式:

def is_valid_deployment_tag(
deployment_tags: Sequence[str], request_tags: Sequence[str], match_any: bool = True
) -> bool:
if not request_tags:
return False
dep_set = set(deployment_tags)
req_set = set(request_tags)

match_any=True 是默认值(交集非空即匹配),False 时要求请求标签是后端标签的子集。还支持正则形式的标签(_is_valid_deployment_tag_regex)。

标签路由是从"路由"通向"多租户"的桥。 一旦你能给请求打上 team:researchenv:prod 这样的标签并据此选后端,配额、计费、隔离就都有了挂载点 —— 这条线在第 04 篇继续。

四、预算过滤

budget_limiter.py 和上面几个策略不是一个层次的东西。它不参与"选谁",而是在选之前先把没钱的后端踢出去:

class RouterBudgetLimiting(CustomLogger):
async def async_filter_deployments(...)
def _filter_out_deployments_above_budget(...)

支持两个粒度,provider 级和 deployment 级:

current_spend = spend_map.get(f"provider_spend:{provider}:{config.budget_duration}", 0.0)
if config.max_budget and current_spend >= config.max_budget:
debug_msg = f"Exceeded budget for provider {provider}: {current_spend} >= {config.max_budget}"
current_spend = spend_map.get(f"deployment_spend:{model_id}:{config.budget_duration}", 0.0)

Key 的构造方式(provider_spend:{provider}:{duration})说明花费是按 provider + 周期聚合的,周期本身是 key 的一部分 —— 换周期等于换一套计数器,天然避免了跨周期污染。

五、容错:Higress 的密钥故障转移

密钥的可用状态是一个带两条恢复路径的状态机:

一把 API Key 的可用状态,以及它回到可用的两条路可用参与正常路由不可用移出可用列表记录失效时刻连续失败次数达到阈值路径一 · 冷却 —— 已满 cooldownDuration,直接放回路径二 · 健康检查 —— 用 healthCheckModel 发真实请求,探测成功才放回两条路径缺一不可:只有冷却,冷却期一过就把流量打回一个可能还没恢复的后端;只有健康检查,探测请求本身也要花钱、也可能被限流。
注意这是个状态机,不是一次性的开关。密钥失效后不会被永久丢弃,两条恢复路径决定了它多久能重新参与路由 —— 冷却是时间驱动的,健康检查是证据驱动的。

两条路径必须至少配置一条,否则失效的密钥永远回不来 —— 配置校验会直接拒绝启动,见 5.1 节。

LiteLLM 的容错做在"后端"这个粒度上。Higress 的 failover.go(764 行)做在更细的一层:单个 API Key

场景很实在 —— 同一个 provider 你配了十个 API Key 轮着用,其中一个被限流或封禁了,不该把整个 provider 判死。

5.1 配置校验:两条恢复路径必选其一

func (f *failover) Validate() error {
if f.healthCheckModel == "" && f.cooldownDuration <= 0 {
return errors.New("either healthCheckModel or cooldownDuration must be configured in failover config")
}

要么配主动健康检查(拿一个真实模型发探测请求),要么配冷却时长(时间到了自动放回)。 两个都不配就直接报错 —— 因为那样失败的 key 会永远出不来。默认值:

f.healthCheckInterval = 5000   // ms
f.healthCheckTimeout = 5000 // ms

5.2 恢复的两条路径

// 路径一:冷却到期,直接恢复
if now-since >= c.failover.cooldownDuration {
log.Infof("cooldown recovery: apiToken %s has cooled down for %dms, restoring to available list", apiToken, now-since)
removeApiToken(c.failover.ctxUnavailableApiTokens, apiToken)
}

// 路径二:主动健康检查,发真实请求探测
if c.failover.healthCheckModel != "" && len(unavailableTokens) > 0 {
for _, apiToken := range unavailableTokens {
healthCheckEndpoint, headers, body := c.generateRequestHeadersAndBody()
// ... 用 healthCheckModel 发一个真实的 completion 请求
}
}

主动健康检查用的是真实的 completion 请求,不是 ping。这一点很重要:API Key 的可用性没法用 TCP 或 HTTP 层探测出来,只有真的发一次推理请求,才知道它是不是还有额度、有没有被封。代价是健康检查本身要花钱。

5.3 Wasm 形态带来的额外复杂度

这段逻辑在库形态里十几行就写完了。但 Higress 是 Wasm 插件,每个 Envoy worker 线程有独立的 Wasm VM,如果每个 VM 都跑健康检查,十个 VM 就是十倍的探测请求和十倍的花费。

于是必须选出一个 VM 来干这件事,靠的是 CAS 租约(第 02 篇提过,这里是完整版):

func (c *ProviderConfig) tryAcquireOrRenewLease(vmID string) bool {
now := time.Now().Unix()
data, cas, err := proxywasm.GetSharedData(c.failover.ctxVmLease)
if err != nil {
if errors.Is(err, types.ErrorStatusNotFound) {
return c.setLease(vmID, now, cas)
}
return false
}
var lease Lease
json.Unmarshal(data, &lease)

// If vmID is itself, try to renew the lease directly
// If the lease is expired (60s), try to acquire the lease
if lease.VMID == vmID || now-lease.Timestamp > 60 {
lease.VMID = vmID
lease.Timestamp = now
return c.setLease(vmID, now, cas)
}
return false
}

setLeaseproxywasm.SetSharedData(key, value, cas) 做 CAS 写入,冲突就重试,上限 casMaxRetries = 10。租约 60 秒过期 —— 持有者所在的 VM 挂了,最多 60 秒后另一个 VM 会接管。

这是一个完整的、跑在 Envoy 进程内的分布式租约实现,只为了决定"谁来发健康检查请求"。

六、路由决策对后端性能的反向影响

最后一个容易被忽略的点:网关的路由策略和推理后端的前缀缓存是耦合的。

vLLM 的 Prefix Caching 靠的是"相同前缀命中同一个实例上已算好的 KV Cache"。如果网关按最低延迟随机打散请求,同一个 system prompt 的请求会被分到不同实例,每个实例都得重算一遍前缀,缓存命中率直接崩掉。

这就是为什么生产级推理平台会做"缓存感知路由"(cache-aware routing)—— 让相同前缀的请求尽量粘在同一个后端上,哪怕它当下不是最快的那个。这条线在 vLLM 推理专题 · Prefix Caching 里有完整展开。

结论:如果你的后端是自建的 vLLM 集群而不是外部 API,那么本文讲的这几种策略都不能直接用 —— 它们全都假设后端是无状态的。

下一篇04 - 多租户与配额:虚拟密钥、预算、限流三件套,看 LiteLLM 和 Envoy AI Gateway 两条完全不同的技术路线。

← 回到 专题索引  ·  Agent Infra 板块总览